Chat Project

Next.js .NET SignalR Entity Framework Core JWT Authentication E2EE
View on GitLab
Chat Application Screenshot

Project Overview

A secure, real-time chat application built as a full-stack portfolio piece. The goal was to create a working product that feels close to a real-world messaging platform. It combines a Next.js frontend with a .NET backend, uses SignalR for live communication, and applies end-to-end encryption so messages are never stored as plain text.

The project demonstrates how I approach software design: separate concerns into focused layers, protect every data flow with authentication and authorization, and make real-time features like messaging and presence feel natural. It shows that I can build a complete feature set and think about how the system behaves as a whole , not just how individual screens look.

Architecture and Design

The solution avoids the monolith trap. Each project has a single responsibility, which keeps the codebase easy to navigate, test, and extend. Authentication, business logic, data access, real-time communication, and the UI are all clearly separated.

chat.front

Next.js, React, TypeScript, Tailwind CSS, SignalR client, IndexedDB (idb)

chat.back

.NET Web API, SignalR hubs, JWT authentication, Swagger docs at /swagger

chat.logic

Services, validation, cryptography , all domain rules in one testable layer

chat.dataAcces

Entity Framework Core , users, messages, tokens, key sets, friend connections

chat.common

Interfaces and models shared across backend layers , one source of truth

How SignalR Is Used

SignalR is the real-time layer of the project. It removes the need for manual refreshes or polling and lets the server push events directly to connected clients. In this application it powers two things: private messaging and presence updates.

MessagingHub

The messaging hub delivers private messages to the correct recipient immediately. Before broadcasting, the hub checks the current user and verifies that the conversation relationship is valid. The real-time layer is not just fast , it is also protected against unauthorised delivery.

MessagingHub concept

public class MessagingHub : Hub
{
    public async Task SendMessage(
        string recipientId, string ciphertext, string iv)
    {
        var senderId = Context.UserIdentifier;

        // Verify friendship before delivery
        bool isFriend = await _friendService
            .AreFriendsAsync(senderId, recipientId);

        if (!isFriend) return;

        // Persist first, then push
        await _messageService.SaveAsync(
            senderId, recipientId, ciphertext, iv);

        await Clients.User(recipientId)
            .SendAsync("ReceiveMessage", senderId, ciphertext, iv);
    }
}
                

PresenceHub

The presence hub tracks who is online. On connect or disconnect, it updates the in-memory presence state and broadcasts the change to relevant clients. It also handles typing indicators, which makes the chat feel immediate and interactive.

PresenceHub concept

public class PresenceHub : Hub
{
    public override async Task OnConnectedAsync()
    {
        var userId = Context.UserIdentifier;
        _presenceTracker.UserConnected(userId);
        await Clients.Others.SendAsync("UserOnline", userId);
    }

    public override async Task OnDisconnectedAsync(Exception? ex)
    {
        var userId = Context.UserIdentifier;
        _presenceTracker.UserDisconnected(userId);
        await Clients.Others.SendAsync("UserOffline", userId);
    }

    public async Task SendTyping(string recipientId)
    {
        await Clients.User(recipientId)
            .SendAsync("UserTyping", Context.UserIdentifier);
    }
}
                

Encryption and Security

Security was a core requirement, not an afterthought. Every sensitive piece of data is protected at rest and in transit, and access is verified at every layer before data is read or written.

End-to-End Encryption

Messages are never stored as plain text. They are saved as ciphertext together with an initialisation vector, meaning the database holds only encrypted payloads. Per-user key sets are stored in dedicated records, so key material is scoped per account rather than shared across the application.

How it works: When a message is sent, the frontend encrypts it using the recipient's key before passing it to the hub. The hub verifies the friendship, then persists the ciphertext and IV as-is. On the receiving end, the client decrypts locally using the stored key. The server never sees the plaintext. This design means that even a full database leak would not expose readable message content.

Zero trust

Every data access operation is protected by authentication and authorization checks. The backend never trusts the client to provide valid user IDs or relationships. For example, the MessagingHub verifies that the sender and recipient are actually friends before allowing a message to be sent, preventing abuse of the real-time layer for spam or harassment.

Next to this input is always validated against expected formats and constraints. The system is designed to fail securely, meaning that any unexpected input or state will result in a safe error rather than a crash or data leak.

Refresh Token Hashing

Refresh tokens are hashed before being stored in the database. The unhashed (raw) refresh token is only returned to the client once at the time of issuance and is never saved in readable form. When a refresh is requested, the provided token is hashed and compared to the stored hash. This approach ensures that, even if the database is compromised, the actual refresh tokens cannot be retrieved or reused.

JWT Authentication

All API endpoints and SignalR hubs require a valid JSON Web Token (JWT) for access. The JWT is issued upon login and contains the user's identity claims. Each request is validated using this token before any data is accessed. When the JWT expires, a refresh token, issued at login and stored securely, can be used to obtain a new JWT and refresh token pair, allowing the session to continue securely without requiring the user to log in again. Refresh tokens are single-use and are invalidated after being used.

TokenService (C#)

using chat.common.Interfaces.ServicesInterfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace chat.logic.Services;

public  class TokenService: ITokenService
{
    private readonly IConfiguration _config;
    public TokenService(IConfiguration config)=> _config = config;
    public string GenerateToken(string id)
    {
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
        new Claim(ClaimTypes.Role, "User"),
        new Claim(ClaimTypes.NameIdentifier, id),
        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
    };

        var token = new JwtSecurityToken(
            issuer: _config["Jwt:Issuer"],
            audience: _config["Jwt:Issuer"],
            claims: claims,
            expires: DateTime.UtcNow.AddHours(1),
            signingCredentials: creds
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}
            

Code Examples

The code example below demonstrates the implementation of the FriendController.

IFriendRepository (C#)

using chat.common.Models;
using chat.common.Models.DataSets;
using chat.common.Models.Requests;

namespace chat.common.Interfaces.RepositoryInterfaces;

public interface IFriendRepository
{
    Task IsPartOfConnection(Guid friendConnectionID, int userId);
    Task> GetFriendsByStatusAsync(int userId, FriendStatus status);
    Task> GetAllAcceptedFriendsAsync(int userId);
    Task> GetUsersByEmailAsync(string email);
    Task GetUserByIdAsync(int userId);
    Task FriendConnectionExistsAsync(int userId, int friendId);
    Task AddFriendConnectionAsync(FriendConnection connection);
    Task GetConnectionAsync(int receiverId, int senderId);
    Task SaveChangesAsync();
}
              
IFriendService (C#)

using chat.common.Models;
using chat.common.Models.Requests;

namespace chat.common.Interfaces.ServicesInterfaces;
public interface IFriendService
{
    Task> GetFriendsByStatusAsync(int userId, FriendStatus status);
    Task> GetAllFriendsAsync(int userId);
    Task InviteFriendAsync(int userId, string email);
    Task UpdateFriendStatusAsync(int userId, int senderId, FriendStatus newStatus);
}

              
FriendController (C#)

using chat.common.Interfaces.RepositoryInterfaces;
using chat.common.Interfaces.ServicesInterfaces;
using chat.common.Models;
using chat.common.Models.Requests;
using chat.dataAcces.Data;
using chat.dataAcces.Repositories;
using chat.logic.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;

namespace chat.back.Controllers;

[ApiController]
[Authorize]
[Route("api/[controller]")]
public class FriendController: ControllerBase
{
    private readonly IFriendService _friendService;

    public FriendController(ChatContext context)
    {
        IFriendRepository friendRepository = new FriendRepository(context);
        _friendService = new FriendService(friendRepository);
    }

    [HttpGet]
    public async Task>> GetFriendsOnStatus(
        [FromQuery] FriendStatus? status = null)
    {
        if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userId))
            return Unauthorized();
        if (!status.HasValue)
            return BadRequest("Select a value for the status");
        try
        {
            var result = await _friendService.GetFriendsByStatusAsync(userId, status.Value);
            return Ok(result);
        }
        catch (Exception ex) { return BadRequest(ex.Message); }
    }

    [HttpGet("All")]
    public async Task>> GetFriends()
    {
        if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userId))
            return Unauthorized();
        try
        {
            var result = await _friendService.GetAllFriendsAsync(userId);
            return Ok(result);
        }
        catch (Exception ex) { return BadRequest(ex.Message); }
    }

    [HttpPost]
    public async Task InviteFriend([FromQuery] string email = null)
    {
        if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userId))
            return Unauthorized();
        try
        {
            await _friendService.InviteFriendAsync(userId, email);
            return Ok(new { message = "Friend invite sent." });
        }
        catch (InvalidDataException ex) { return BadRequest(ex.Message); }
        catch (Exception ex) { return BadRequest(ex.Message); }
    }

    [HttpPut]
    public async Task UpdateFriendStatus(
        [FromQuery] FriendStatus? newStatus = null, int senderId = default)
    {
        if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userId))
            return Unauthorized();
        if (newStatus == null || senderId == default)
            return BadRequest("Status or sender id is null");
        try
        {
            await _friendService.UpdateFriendStatusAsync(userId, senderId, newStatus.Value);
            return Ok("Updated status");
        }
        catch (Exception ex) { return BadRequest(ex.Message); }
    }
}

              
FriendService (C#)

using chat.common.Interfaces.RepositoryInterfaces;
using chat.common.Interfaces.ServicesInterfaces;
using chat.common.Models;
using chat.common.Models.DataSets;
using chat.common.Models.Requests;

namespace chat.logic.Services;
public class FriendService : IFriendService
{
    private readonly IFriendRepository _repo;

    public FriendService(IFriendRepository repo)
    {
        _repo = repo;
    }

    public async Task> GetFriendsByStatusAsync(int userId, FriendStatus status) =>
        await _repo.GetFriendsByStatusAsync(userId, status);

    public async Task> GetAllFriendsAsync(int userId) =>
        await _repo.GetAllAcceptedFriendsAsync(userId);

    public async Task InviteFriendAsync(int userId, string email)
    {
        var user = await _repo.GetUserByIdAsync(userId)
            ?? throw new InvalidDataException("User not found");

        if (email == user.Email)
            throw new Exception("Cannot add yourself");

        var wantedFriends = await _repo.GetUsersByEmailAsync(email);
        if (wantedFriends.Count == 0)
            throw new Exception("User doesn't exist");

        var newFriend = wantedFriends.First();

        if (await _repo.FriendConnectionExistsAsync(userId, newFriend.Id))
            throw new Exception("Already invited");

        await _repo.AddFriendConnectionAsync(new FriendConnection
        {
            DateTime = DateTime.Now,
            Sender = user,
            Receiver = newFriend,
            Status = FriendStatus.Pending
        });

        await _repo.SaveChangesAsync();
    }

    public async Task UpdateFriendStatusAsync(int userId, int senderId, FriendStatus newStatus)
    {
        var connection = await _repo.GetConnectionAsync(userId, senderId);
        connection.Status = newStatus;
        await _repo.SaveChangesAsync();
    }
}

              
FriendRepository (C#)

using chat.common.Interfaces.RepositoryInterfaces;
using chat.common.Models;
using chat.common.Models.DataSets;
using chat.common.Models.Requests;
using chat.dataAcces.Data;
using Microsoft.EntityFrameworkCore;

namespace chat.dataAcces.Repositories;

public class FriendRepository:IFriendRepository
{
    private readonly ChatContext _context;
    public FriendRepository(ChatContext context)
    {
        _context = context;
    }

    private async Task> GetLatestPublicKeysByOwnerIdsAsync(IEnumerable ownerIds)
    {
        var distinctOwnerIds = ownerIds.Distinct().ToList();
        if (distinctOwnerIds.Count == 0)
            return new Dictionary();

        var keyRows = await _context.Keysets
            .Where(k => distinctOwnerIds.Contains(k.OwnerId))
            .OrderByDescending(k => k.Id)
            .Select(k => new { k.OwnerId, k.PublicKey })
            .ToListAsync();

        var result = new Dictionary();
        foreach (var row in keyRows)
        {
            if (!result.ContainsKey(row.OwnerId))
            {
                result[row.OwnerId] = row.PublicKey;
            }
        }

        return result;
    }

    public async Task IsPartOfConnection(Guid friendConnectionID,int userId) =>
        await _context.FriendConnections.AnyAsync(fc => fc.Id == friendConnectionID
        && (fc.SenderId == userId || fc.ReceiverId == userId));
    public async Task> GetFriendsByStatusAsync(int userId, FriendStatus status)
    {
        var friendRows = await (
            from f in _context.FriendConnections
            where f.ReceiverId == userId && f.Status == status
            orderby f.DateTime
            join user in _context.Users on f.SenderId equals user.Id
            select new
            {
                UserId = user.Id,
                user.Username,
            }
        ).ToListAsync();

        var keyMap = await GetLatestPublicKeysByOwnerIdsAsync(friendRows.Select(r => r.UserId));

        return friendRows
            .Where(r => keyMap.ContainsKey(r.UserId))
            .Select(r => new FriendStatusSetRequest
            {
                Id = r.UserId,
                Username = r.Username,
                PublicKey = keyMap[r.UserId]
            })
            .ToList();
    }

    public async Task> GetAllAcceptedFriendsAsync(int userId)
    {
        var friendRows = await (
            from f in _context.FriendConnections
            where (f.ReceiverId == userId || f.SenderId == userId)
                  && f.Status == FriendStatus.Accepted
            orderby f.DateTime
            join user in _context.Users
                on (f.SenderId == userId ? f.ReceiverId : f.SenderId) equals user.Id
            select new
            {
                FriendConnectionId = f.Id,
                UserId = user.Id,
                user.Username,
                f.DateTime
            }
        ).ToListAsync();

        var keyMap = await GetLatestPublicKeysByOwnerIdsAsync(friendRows.Select(r => r.UserId));

        return friendRows
            .Where(r => keyMap.ContainsKey(r.UserId))
            .Select(r => new FriendStatusSetRequest
            {
                FriendId = r.FriendConnectionId.ToString(),
                Id = r.UserId,
                Username = r.Username,
                PublicKey = keyMap[r.UserId],
                ConnectionDateTime = r.DateTime
            })
            .ToList();
    }

    public async Task> GetUsersByEmailAsync(string email) =>
        await _context.Users.Where(u => u.Email.Equals(email)).ToListAsync();

    public async Task GetUserByIdAsync(int userId) =>
        await _context.Users.Where(u => u.Id == userId).FirstOrDefaultAsync();

    public async Task FriendConnectionExistsAsync(int userId, int friendId) =>
        await _context.FriendConnections.AnyAsync(f =>
            (f.SenderId == userId && f.ReceiverId == friendId) ||
            (f.SenderId == friendId && f.ReceiverId == userId));

    public async Task AddFriendConnectionAsync(FriendConnection connection) =>
        await _context.FriendConnections.AddAsync(connection);

    public async Task GetConnectionAsync(int receiverId, int senderId) =>
        await _context.FriendConnections
            .Where(f => f.ReceiverId == receiverId && f.Sender.Id == senderId)
            .FirstAsync();


    public async Task SaveChangesAsync() =>
    await _context.SaveChangesAsync();
}

              
FriendConnection (C#)

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace chat.common.Models.DataSets;

public class FriendConnection
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public Guid Id { get; set; } = Guid.NewGuid();

    public int SenderId { get; set; }
    [Required]
    public User Sender { get; set; } = null!;

    public int ReceiverId { get; set; }
    [Required]
    public User Receiver { get; set; } = null!;

    public DateTime DateTime { get; set; }
    public FriendStatus Status { get; set; }
}
              

Future updates

This project was built as part of my learning journey. I plan to revisit and enhance it over time, adding more features and improving the codebase based on new knowledge and feedback. The architecture is designed to be extensible, so I can add things like group chats, media messages, or improved encryption schemes in the future without major refactoring.

01
New Alert Hub
Implement real-time alerting system for incomming invites or messages
02
Message Improvements
Delete, edit, and message information
03
Group Chats
Create and manage group conversations
04
Sending Images
Upload and share images within conversations
05
Custom SignalR Hubs
Implement custom hubs for specific functionality and extra security